Skip to main content

React Server Side Rendering

This example explains how to use Piscina for server-side rendering (SSR) of React components. We'll compare a pooled version using Piscina with an unpooled version to highlight the benefits of using a thread pool for SSR.

To get started, make sure you have the following dependencies installed:

npm install fastify fastify-piscina react react-dom lorem-ipsum

If you are using TypeScript in your project, also install:

npm install -D typescript @types/react @types/react-dom @types/node

The function in the worker file uses ReactDOMServer.renderToString() to render two React components: Greeting and Lorem.

worker.js
'use strict';

const React = require('react');
const ReactDOMServer = require('react-dom/server');
const { Greeting, Lorem } = require('./components');

module.exports = ({ name }) => {
return `
<!doctype html>
<html>
<body>
<div id="root">${
ReactDOMServer.renderToString(React.createElement(Greeting, { name }))
}</div>
${
ReactDOMServer.renderToString(React.createElement(Lorem))
}
<script src="/static/home.js"></script>
</body>
</html>`;
};

Here are the components used in this example:

components/greeting.js
'use strict';

const React = require('react');

class Greeting extends React.Component {
render () {
return React.createElement('div', null, 'hello ' + this.props.name);
}
}

module.exports = Greeting;
components/lorem.js
'use strict';

const React = require('react');
const { LoremIpsum } = require('lorem-ipsum');

class Paragraph extends React.Component {
#lorem;

constructor (props) {
super(props);
this.#lorem = new LoremIpsum({
sentencesPerParagraph: {
max: 8,
min: 4
},
wordsPerSentence: {
max: 16,
min: 4
}
});
}

render () {
return React.createElement('div', null, this.#lorem.generateParagraphs(1));
}
}

class Lorem extends React.Component {
render () {
const children = [];
for (let n = 0; n < Math.floor(Math.random() * 50); n++) {
children.push(React.createElement(Paragraph, { key: n }));
}
return React.createElement('div', null, children);
}
}

module.exports = Lorem;

Pooled Version

In pooled version of the code, we set up a Fastify server with Piscina integration. We register the fastify-piscina plugin, configuring it to use the worker.js file with 6 threads. The root route (/) uses fastify.runTask() to execute the rendering in a worker thread.

pooled.js
'use strict';

const fastify = require('fastify')();
const { resolve } = require('path');

fastify.register(require('fastify-piscina'), {
filename: resolve(__dirname, 'worker.js'),
execArgv: [],
minThreads: 6,
maxThreads: 6
});

// Declare a route
fastify.get('/', async () => fastify.runTask({ name: 'James' }));

// Run the server!
const start = async () => {
try {
await fastify.listen(3000);
} catch (err) {
process.exit(1);
}
};
start();

process.on('SIGINT', () => {
const waitTime = fastify.piscina.waitTime;
console.log('\nMax Queue Wait Time:', waitTime.max);
console.log('Mean Queue Wait Time:', waitTime.mean);
process.exit(0);
});

Unpooled Version

In the unpooled version of the server, we perform the same rendering as the pooled version, but with no thread pool or worker management. The root route (/) renders the React components synchronously in the request handler.

unpooled.js
'use strict';

const fastify = require('fastify')();
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const { Greeting, Lorem } = require('./components');

// Declare a route
fastify.get('/', async () => {
const name = 'James';
return `
<!doctype html>
<html>
<body>
<div id="root">${
ReactDOMServer.renderToString(React.createElement(Greeting, { name }))
}</div>
${
ReactDOMServer.renderToString(React.createElement(Lorem))
}
<script src="/static/home.js"></script>
</body>
</html>`;
});

// Run the server!
const start = async () => {
try {
await fastify.listen(3000);
} catch (err) {
process.exit(1);
}
};
start();

Run either the pooled or unpooled version:

node pooled.js

Access http://localhost:3000 in your browser to see the rendered page.

Benchmarking Results

We used autocannon to benchmark both the pooled and unpooled versions.

Install autocannon:

npm i -g autocannon

Then benchmark the results by running the script below:

autocannon http://localhost:3000

Here are the results:

Pooled Version Result

Running 10s test @ http://localhost:3000
10 connections

┌─────────┬──────┬──────┬───────┬───────┬────────┬──────────┬────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼──────┼──────┼───────┼───────┼────────┼──────────┼────────┤
│ Latency │ 2 ms │ 4 ms │ 44 ms │ 63 ms │ 6.9 ms │ 10.98 ms │ 190 ms │
└─────────┴──────┴──────┴───────┴───────┴────────┴──────────┴────────┘
┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬─────────┬────────┐
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼────────┤
│ Req/Sec │ 657 │ 657 │ 1,225 │ 2,067 │ 1,348.9 │ 454.88 │ 657 │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼────────┤
│ Bytes/Sec │ 2.3 MB │ 2.3 MB │ 4.35 MB │ 7.12 MB │ 4.68 MB │ 1.55 MB │ 2.3 MB │
└───────────┴────────┴────────┴─────────┴─────────┴─────────┴─────────┴────────┘

Req/Bytes counts sampled once per second.
# of samples: 10

13k requests in 10.07s, 46.8 MB read

Unpooled Version Result

Running 10s test @ http://localhost:3000
10 connections

┌─────────┬──────┬───────┬───────┬───────┬──────────┬──────────┬────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼──────┼───────┼───────┼───────┼──────────┼──────────┼────────┤
│ Latency │ 8 ms │ 22 ms │ 63 ms │ 75 ms │ 25.03 ms │ 14.78 ms │ 165 ms │
└─────────┴──────┴───────┴───────┴───────┴──────────┴──────────┴────────┘
┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬────────┬────────┐
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
│ Req/Sec │ 234 │ 234 │ 419 │ 590 │ 395.6 │ 127.26 │ 234 │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
│ Bytes/Sec │ 785 kB │ 785 kB │ 1.47 MB │ 1.97 MB │ 1.36 MB │ 439 kB │ 785 kB │
└───────────┴────────┴────────┴─────────┴─────────┴─────────┴────────┴────────┘

Req/Bytes counts sampled once per second.
# of samples: 10

4k requests in 10.22s, 13.6 MB read

The pooled version using Piscina offers several advantages for server-side rendering:

  • It handles significantly more requests per second (1,348.9 vs 395.6 on average).
  • It has lower latency (6.9ms vs 25.03ms on average).
  • It processes more data (4.68 MB/sec vs 1.36 MB/sec on average).

You can also check out this example on github.